UserProfileEditDialog.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. 'use client';
  2. import { useRef, useState } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { ImageIcon, Camera, Trash2, User } from 'lucide-react';
  5. import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import { useMemberContext } from '@/contexts/memberProvider';
  8. import { UserProfileDto } from '@/types/account/profile';
  9. type Props = {
  10. open: boolean;
  11. onOpenChange: (open: boolean) => void;
  12. profile: UserProfileDto;
  13. };
  14. export default function UserProfileEditDialog({ open, onOpenChange, profile }: Props) {
  15. const router = useRouter();
  16. const { member, setMember } = useMemberContext();
  17. const persistMember = (next: typeof member) => {
  18. if (!next) {
  19. return;
  20. }
  21. setMember(next);
  22. try {
  23. localStorage.setItem('member', JSON.stringify(next));
  24. document.cookie = `member=${encodeURIComponent(JSON.stringify(next))}; path=/; max-age=${60 * 60 * 24 * 30}`;
  25. } catch {
  26. // ignore
  27. }
  28. };
  29. const [bannerFile, setBannerFile] = useState<File|null>(null);
  30. const [bannerPreview, setBannerPreview] = useState<string|null>(profile.bannerUrl);
  31. const [bannerRemoved, setBannerRemoved] = useState<boolean>(false);
  32. const [thumbFile, setThumbFile] = useState<File|null>(null);
  33. const [thumbPreview, setThumbPreview] = useState<string|null>(profile.thumb);
  34. const [name, setName] = useState<string>(profile.name ?? '');
  35. const [intro, setIntro] = useState<string>(profile.intro ?? '');
  36. const [submitting, setSubmitting] = useState(false);
  37. const [error, setError] = useState<string|null>(null);
  38. const bannerInputRef = useRef<HTMLInputElement|null>(null);
  39. const thumbInputRef = useRef<HTMLInputElement|null>(null);
  40. const handleBannerSelect = (files: FileList|null) => {
  41. const file = files?.[0];
  42. if (!file || !file.type.startsWith('image/')) {
  43. return;
  44. }
  45. setBannerFile(file);
  46. setBannerRemoved(false);
  47. const reader = new FileReader();
  48. reader.onloadend = () => setBannerPreview(reader.result as string);
  49. reader.readAsDataURL(file);
  50. };
  51. const handleBannerRemove = () => {
  52. setBannerFile(null);
  53. setBannerPreview(null);
  54. setBannerRemoved(true);
  55. };
  56. const handleThumbSelect = (files: FileList|null) => {
  57. const file = files?.[0];
  58. if (!file || !file.type.startsWith('image/')) {
  59. return;
  60. }
  61. setThumbFile(file);
  62. const reader = new FileReader();
  63. reader.onloadend = () => setThumbPreview(reader.result as string);
  64. reader.readAsDataURL(file);
  65. };
  66. const handleSubmit = async () => {
  67. if (!member || submitting) {
  68. return;
  69. }
  70. setSubmitting(true);
  71. setError(null);
  72. try {
  73. const nameChanged = name.trim() !== (profile.name ?? '');
  74. const introChanged = intro !== (profile.intro ?? '');
  75. if (bannerFile) {
  76. const form = new FormData();
  77. form.append('banner', bannerFile);
  78. const res = await fetchApi<{ bannerUrl: string }>('/api/mypage/banner', { method: 'POST', body: form, silent: true });
  79. if (!res.success) {
  80. throw new Error(res.message ?? '배너 업로드 실패');
  81. }
  82. } else if (bannerRemoved && profile.bannerUrl) {
  83. const res = await fetchApi('/api/mypage/banner', { method: 'DELETE', silent: true });
  84. if (!res.success) {
  85. throw new Error(res.message ?? '배너 삭제 실패');
  86. }
  87. }
  88. if (thumbFile) {
  89. const form = new FormData();
  90. form.append('thumb', thumbFile);
  91. const res = await fetchApi<{ thumbUrl: string }>('/api/mypage/thumb', { method: 'POST', body: form, silent: true });
  92. if (!res.success) {
  93. throw new Error(res.message ?? '프로필 사진 업로드 실패');
  94. }
  95. if (member && res.data?.thumbUrl !== undefined) {
  96. persistMember({ ...member, thumb: res.data.thumbUrl });
  97. }
  98. }
  99. if (nameChanged) {
  100. const res = await fetchApi('/api/mypage/name', { method: 'POST', body: { Name: name.trim() }, silent: true });
  101. if (!res.success) {
  102. throw new Error(res.message ?? '별명 변경 실패');
  103. }
  104. if (member) {
  105. persistMember({ ...member, name: name.trim() });
  106. }
  107. }
  108. if (introChanged) {
  109. const res = await fetchApi('/api/mypage/intro', { method: 'POST', body: { Intro: intro }, silent: true });
  110. if (!res.success) {
  111. throw new Error(res.message ?? '자기소개 변경 실패');
  112. }
  113. if (member) {
  114. persistMember({ ...member, intro });
  115. }
  116. }
  117. onOpenChange(false);
  118. router.refresh();
  119. } catch (err) {
  120. setError(err instanceof Error ? err.message : '저장 실패');
  121. } finally {
  122. setSubmitting(false);
  123. }
  124. };
  125. return (
  126. <Dialog open={open} onOpenChange={onOpenChange}>
  127. <DialogContent className="user-profile__edit-dialog">
  128. <DialogHeader>
  129. <DialogTitle>프로필 편집</DialogTitle>
  130. </DialogHeader>
  131. <div className="user-profile__edit-body">
  132. <div className="user-profile__edit-banner">
  133. {bannerPreview ? (
  134. <img src={bannerPreview} alt="배너 미리보기" />
  135. ) : (
  136. <div className="user-profile__edit-banner-placeholder" />
  137. )}
  138. <div className="user-profile__edit-banner-actions">
  139. <button type="button" className="user-profile__edit-banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="배너 변경">
  140. <ImageIcon size={18} />
  141. </button>
  142. {bannerPreview && (
  143. <button type="button" className="user-profile__edit-banner-btn" onClick={handleBannerRemove} aria-label="배너 제거">
  144. <Trash2 size={18} />
  145. </button>
  146. )}
  147. </div>
  148. <input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(e) => handleBannerSelect(e.target.files)} />
  149. </div>
  150. <div className="user-profile__edit-avatar-wrap">
  151. {thumbPreview ? (
  152. <img src={thumbPreview} alt="프로필 사진 미리보기" className="user-profile__edit-avatar" />
  153. ) : (
  154. <span className="user-profile__edit-avatar user-profile__edit-avatar--empty" aria-hidden="true">
  155. <User size={40} strokeWidth={1.5} />
  156. </span>
  157. )}
  158. <button type="button" className="user-profile__edit-avatar-btn" onClick={() => thumbInputRef.current?.click()} aria-label="프로필 사진 변경">
  159. <Camera size={16} />
  160. </button>
  161. <input ref={thumbInputRef} type="file" accept="image/*" hidden onChange={(e) => handleThumbSelect(e.target.files)} />
  162. </div>
  163. <div className="user-profile__edit-fields">
  164. <label className="user-profile__edit-field">
  165. <span>별명</span>
  166. <input type="text" value={name} onChange={(e) => setName(e.target.value)} maxLength={20} disabled={submitting} />
  167. </label>
  168. <label className="user-profile__edit-field">
  169. <span>자기소개</span>
  170. <textarea value={intro} onChange={(e) => setIntro(e.target.value)} rows={4} maxLength={500} placeholder="조금 더 자세한 자기소개" disabled={submitting} />
  171. </label>
  172. </div>
  173. {error && <p className="user-profile__edit-error">{error}</p>}
  174. <div className="user-profile__edit-footer">
  175. <button type="button" className="user-profile__edit-cancel" onClick={() => onOpenChange(false)} disabled={submitting}>취소</button>
  176. <button type="button" className="user-profile__edit-save" onClick={handleSubmit} disabled={submitting}>
  177. {submitting ? '저장 중...' : '저장'}
  178. </button>
  179. </div>
  180. </div>
  181. </DialogContent>
  182. </Dialog>
  183. );
  184. }